Box SDK PythonでLambda関数を構築するために、AWS CDKを使用する

Box SDK PythonでLambda関数を構築するために、AWS CDKを使用する

Box Python SDKを使って、Lambda関数からBoxを操作する必要がありましたので、その構築方法をまとめてみました。Lambda関数はAWS CDKでリソース定義しました。
Clock Icon2024.07.16

データ事業本部の笠原です。

Box Python SDKを使って、Lambda関数からBoxを操作する必要がありましたので、その構築方法をまとめてみました。Lambda関数はAWS CDKでリソース定義しました。

AWS CDKはTypeScript、Lambda関数はPythonランタイムを利用しています。

構成図

簡単な構成図を示します。

Box認証はJWTとし、EventBridgeで時間駆動でLambda関数を動かして、Boxの所定ディレクトリ配下にあるファイルを、S3バケットに配置しようと思います。
また、Box認証情報はSSM Parameter Storeに格納します。

arch_box_etl_kas

環境

  • Node.js 20.13.1
    • AWS CDK 2.148.0
    • TypeScript 5.5.3
    • @aws-cdk/aws-lambda-python-alpha 2.148.0-alpha.0
  • Python 3.12.3
    • box-sdk-gen[jwt] 1.0.0

以前の Box Python SDK が Deprecation となっていましたので、新しい Box Python SDK を利用しました。

Boxアプリケーションの設定

Boxアプリケーションを設定します。

今回のBoxアプリケーションは、サーバ側から「Boxにあるファイルをダウンロードする」作業を自動化するイメージなので、「カスタムアプリ」で「JWT」の認証で実施していこうと思います。

1. Box開発者コンソールからBoxアプリケーション作成

Box開発者コンソール から「マイアプリ」内の「アプリの新規作成」をクリックします。

アプリタイプは「カスタムアプリ」をクリックし、アプリ名などを適宜入力します。

01_box_create_app_1

02_box_create_app_2

認証方法は「サーバー認証 (JWT使用)」を選択して、アプリの作成を行います。

03_box_create_app_3

2. Boxアプリケーション設定

今回はファイルのダウンロードを行うので、「構成」タブの「アプリケーションスコープ」にて、「Boxに格納されているすべてのファイルとフォルダへの書き込み」にチェックを入れます。

04_box_config_app_1

3. Boxアプリケーション申請と承認

一通り設定したら、「承認」タブにて該当Boxアプリケーションを申請します。「確認して送信」ボタンをクリックすると申請できます。

申請後は、Box管理者の承認をしてもらう必要があります。

なお、承認後にBoxアプリケーションの設定を変更した場合は、再度申請・承認が必要になりますので、ご注意ください。

05_box_apply_app_1

4. サービスアカウントへBoxフォルダを共有

Boxアプリケーションが承認されたら、「一般構成」タブ内に「サービスアカウント」が自動生成されていると思います。

06_box_sa_share_1

このサービスアカウントを、Boxアプリケーションで操作したいBoxフォルダに対して共有設定をします。
「ユーザを招待」欄にサービスアカウントのメールアドレスを貼り付けて、編集者として招待しましょう。

07_box_sa_share_2

これで、Boxアプリケーションのサービスアカウントが該当フォルダにアクセスすることが可能になりました。

5. 接続確認

認証に必要な秘密鍵を生成します。

「構成」タブ内にある「公開キーの追加と管理」にて、「公開/秘密キーペアを生成」ボタンをクリックします。

06_box_gen_key_1

これによって、BoxアプリケーションのJWTリクエストに署名して認証するためのRSAキーペアを生成することができます。
認証情報はjsonファイルになっており、中身に秘密鍵を含んでいます。

このjsonファイルの内容は、この後のAWSの設定にて、SSM Parameter Storeに格納します。

AWS CDKとLambda関数の設定

CDKアプリケーションの初期化からLambda関数の作成、デプロイまでを簡単にまとめました。

元のソースコードは以下のリポジトリにありますので、ご参考ください。

1. CDKアプリケーションの初期化

mkdir box-etl-sample
cd box-etl-sample
cdk init app --language typescript
npm i -D @aws-cdk/aws-lambda-python-alpha  ## 追加ライブラリインストール
## AWSアカウント上で初めてcdk使う場合に実行
cdk bootstrap

2. Lambda関数の作成

Lambda関数は以下のようにします。

2.1. Box認証

最初にBox認証情報を取得します。

## Main Hander
def lambda_handler(event, context):
    auth: BoxJWTAuth = boxAuth()
    box_client: BoxClient = BoxClient(auth=auth)
    ## <省略>

SSMパラメータストアにBox認証情報のJSON文字列を格納しておき、
その内容を取得して設定します。

## Box Authentication
def boxAuth() -> Authentication:
    box_param_key = os.environ.get('BOX_PARAM_KEY')
    ssm_client = boto3.client('ssm')
    ## Get SSM Parameter Store
    ssm_param = ssm_client.get_parameter(
        Name=box_param_key,
        WithDecryption=True,
    )
    jwt_key_config = ssm_param["Parameter"]["Value"]
    ## Set Box Auth Config
    config = JWTConfig.from_config_json_string(jwt_key_config)
    auth = BoxJWTAuth(config)
    return auth

2.2. Boxファイルダウンロード

以下のメソッドを利用しています。

  • box_client.folders.get_folder_items(box_folder_id).entries
    • 該当のBoxフォルダID内のアイテム(ファイルやフォルダ)の一覧を取得する
  • box_client.downloads.download_file(file_id=item.id)
    • 該当のBoxファイルIDのファイルをダウンロードする

ダウンロードの際は、 BufferedIOBase のストリーム型になっているので、
ファイルで保存する場合は、 shutil.copyfileobj() メソッドでファイル書き込みをしています。

    ## get files
    filelist = []
    items = box_client.folders.get_folder_items(box_folder_id).entries
    for item in items:
        if item.type == 'file':
            ## ファイルだけダウンロード対象とする
            file_name = item.name
            file_path = os.path.join(tmp_dir, file_name)
            ## Write the Box file contents to tmp storage
            file_content_stream: BufferedIOBase = box_client.downloads.download_file(file_id=item.id)
            with open(file_path, 'wb') as f:
                shutil.copyfileobj(file_content_stream, f)
            print(f"Downloaded File: '{file_name}'")
            filelist.append({
                'id': item.id,
                'name': item.name,
                'download_name': file_name,
                'download_path': os.path.abspath(file_path),
            })

2.3. Lambda関数全体

Lambda関数全体は以下のように実装してみました。
一旦 /tmp にファイルをダウンロードしてからS3にアップロードしているので、
あまりファイル数や容量が多いと失敗するかもしれません。

box_to_s3.py
src/lambda/handler/box_to_s3.py
from box_sdk_gen import BoxClient, BoxJWTAuth, JWTConfig, Authentication
import boto3
import os
import shutil
from io import BufferedIOBase
from pathlib import Path

## Box Authentication
def boxAuth() -> Authentication:
    box_param_key = os.environ.get('BOX_PARAM_KEY')
    ssm_client = boto3.client('ssm')
    ## Get SSM Parameter Store
    ssm_param = ssm_client.get_parameter(
        Name=box_param_key,
        WithDecryption=True,
    )
    jwt_key_config = ssm_param["Parameter"]["Value"]
    ## Set Box Auth Config
    config = JWTConfig.from_config_json_string(jwt_key_config)
    auth = BoxJWTAuth(config)
    return auth

## Get bucket name and key name from s3 url
def split_s3_path(s3_path: str) -> tuple[str, str]:
    path_parts = s3_path.replace("s3://", "").split("/")
    bucket = path_parts.pop(0)
    key = "/".join(path_parts)
    return bucket, key

## Main Hander
def lambda_handler(event, context):
    auth: BoxJWTAuth = boxAuth()
    box_client: BoxClient = BoxClient(auth=auth)

    ## configure
    box_folder_id = str(event.get('input_box_folder_id'))
    s3_url = event.get('output_s3_url')
    s3_bucket, s3_objpath = split_s3_path(s3_url)

    ## init tmp dir
    tmp_dir = os.path.join("/tmp", "etl_sample")
    if os.path.exists(tmp_dir):
        shutil.rmtree(tmp_dir)
    os.makedirs(tmp_dir)

    ## get files
    filelist = []
    items = box_client.folders.get_folder_items(box_folder_id).entries
    for item in items:
        if item.type == 'file':
            ## ファイルだけダウンロード対象とする
            file_name = item.name
            file_path = os.path.join(tmp_dir, file_name)
            ## Write the Box file contents to tmp storage
            file_content_stream: BufferedIOBase = box_client.downloads.download_file(file_id=item.id)
            with open(file_path, 'wb') as f:
                shutil.copyfileobj(file_content_stream, f)
            print(f"Downloaded File: '{file_name}'")
            filelist.append({
                'id': item.id,
                'name': item.name,
                'download_name': file_name,
                'download_path': os.path.abspath(file_path),
            })

    ## upload to s3
    s3 = boto3.resource('s3')
    bucket = s3.Bucket(s3_bucket)
    for file in filelist:
        download_filepath = file.get('download_path')
        download_filename = file.get('download_name')
        objkey = os.path.join(s3_objpath, download_filename)
        bucket.upload_file(download_filepath, objkey)
        print(f"Uploaded File: 's3://{s3_bucket}/{objkey}'")

    ## remove tmp
    if os.path.exists(tmp_dir):
        shutil.rmtree(tmp_dir)

    return {
        'statusCode': 200,
    }

if __name__ == '__main__':
    event = {
        'input_box_folder_id': '0',
        'output_s3_url': 's3://box-file-bucket-test/box/etl-sample/',
    }
    lambda_handler(event, None)

3. CDKスタックの定義

CDKスタックは以下のようにしました。

box-etl-sample-stack.ts
lib/box-etl-sample-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { ServicePrincipal, ManagedPolicy } from 'aws-cdk-lib/aws-iam';
import { Bucket } from 'aws-cdk-lib/aws-s3';
import { PythonFunction } from '@aws-cdk/aws-lambda-python-alpha';
import { PythonLayerVersion } from "@aws-cdk/aws-lambda-python-alpha";
import * as targets from 'aws-cdk-lib/aws-events-targets';
import { Rule, Schedule, RuleTargetInput } from 'aws-cdk-lib/aws-events';

export class BoxEtlSampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const inputBoxFolderId = '<Box Folder ID>';
    const boxParameterKey = '/box/sample/key_config';

    // IAM Role
    const lambdaRole = new cdk.aws_iam.Role(this, 'BoxToS3LambdaRole', {
      roleName: 'BoxToS3LambdaRole',
      assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
    });
    lambdaRole.addManagedPolicy(
      ManagedPolicy.fromAwsManagedPolicyName(
        "service-role/AWSLambdaBasicExecutionRole"
      )
    );
    lambdaRole.addManagedPolicy(
      ManagedPolicy.fromAwsManagedPolicyName(
        "AmazonSSMReadOnlyAccess"
      )
    );
    lambdaRole.addManagedPolicy(
      ManagedPolicy.fromAwsManagedPolicyName(
        "AmazonS3FullAccess"
      )
    );

    // S3 Bucket
    const bucket = new Bucket(this, 'FileBucket', {
      bucketName: "box-file-bucket-test"
    });

    // Lambda Layer
    const boxSdkLambdaLayer = new PythonLayerVersion(this, 'BoxToS3LambdaLayer', {
      layerVersionName: 'boxSdkLayer',
      entry: 'src/lambda/layer/box-sdk-layer',
      compatibleRuntimes: [cdk.aws_lambda.Runtime.PYTHON_3_12]
    });

    // Lambda Function
    const lambdaFunction = new PythonFunction(this, 'BoxToS3LambdaFunction', {
      functionName: 'boxToS3',
      runtime: cdk.aws_lambda.Runtime.PYTHON_3_12,
      entry: "src/lambda/handler",
      index: "box_to_s3.py",
      handler: "lambda_handler",
      role: lambdaRole,
      memorySize: 512,
      timeout: cdk.Duration.minutes(15),
      layers: [boxSdkLambdaLayer],
      environment: {
        BOX_PARAM_KEY: boxParameterKey,
        BUCKET_NAME: bucket.bucketName,
      },
    });

    // EventBridge Rule
    const ebrule = new Rule(this, 'boxFileDownloadExecRule', {
      // invoke function AM5:00(JST) everyday.
      schedule: Schedule.cron({minute: "0", hour: "20"}),
      targets: [
        new targets.LambdaFunction(lambdaFunction, {
          retryAttempts: 3,
          event: RuleTargetInput.fromObject({
            input_box_folder_id: inputBoxFolderId,  // Box Folder ID
            output_s3_url: `s3://${bucket.bucketName}/box/etl-sample/`  // S3 URL
          })
        })
      ]
    });
  }
}

4. SSMパラメータストアにBox認証情報を格納

Box認証情報のjsonファイルの内容をAWS Systems Manager(SSM)のパラメータストアに格納します。
パラメータストアには、AWS CLIで登録しました。
認証情報ですので、SecureStringで保存しましょう。

Box認証情報のjsonファイルを box_key_config.json とし、
このファイルがあるディレクトリ上で以下のようなコマンドを実行します。

aws ssm put-parameter \
  --name "/box/sample/key_config" \
  --type "SecureString" \
  --value file://./box_key_config.json

5. デプロイ

以下のコマンドを実行すれば、AWSにデプロイされます。

cdk deploy

6. 動作確認

朝5時に自動起動しますが、とりあえずLambda関数の動作確認をしたい場合は、
Lambdaのテストイベントにて以下のようなJSONを入力値として与えてあげてテスト実行すれば確認できます。

JSONの値は仮の値なので、実際の値を設定して実行してください。

{
    "input_box_folder_id": "0",
    "output_s3_url": "s3://outputBucket/box/etl-sample/"
}

09_box_s3_input_1

実行後、Boxの該当フォルダID配下にあるファイルがS3バケットにファイルが格納されていれば成功です。

10_box_s3_output_1

まとめ

今回はサーバーサイドで動かすBoxアプリケーションの設定とBox Python SDKを利用したLambda関数およびCDKによる定義とデプロイを簡単にまとめました。

Box Python SDKは以前のSDKと比べると、Box APIに近い書き方になっている感じがしました。
今後はこのSDKを使うことになると思いますので、
Boxアプリケーションを作成する際のご参考になれば、幸いです。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.